feat(electron): add --export CLI flag for headless project rendering#628
feat(electron): add --export CLI flag for headless project rendering#628agentiknet wants to merge 1 commit into
Conversation
Opens a hidden editor window, applies a .openscreen project, calls
handleExport() directly, and writes the rendered MP4/GIF to the path
passed via --output. No UI is shown, no save dialog opens.
Usage:
Openscreen --export <project.openscreen> --output <out.mp4|gif>
[--format mp4|gif] [--quality good|medium|source]
Implementation:
- main.ts parses CLI args, forces HEADLESS=true, overrides the
pick-export-save-path + write-export-to-path IPCs to route the blob
to --output, then signals the renderer via "trigger-headless-export".
- preload.ts exposes onHeadlessExportTrigger() with a typed payload.
- VideoEditor.tsx listens for the trigger, scans annotations for
fontFamily references and preloads them via addCustomFont() so
ctx.font doesn't silently fall back to sans-serif, applies the
project state, waits for video.readyState>=2, then calls
handleExport(settings) directly.
Enables batch rendering of OpenScreen projects from CI / shell scripts.
📝 WalkthroughWalkthroughThis PR adds command-line driven video export to Electron, skipping the normal UI. CLI args ( ChangesHeadless Export Feature
Estimated code review effort🎯 4 (Complex) | ⏱️ ~45 minutes Suggested reviewers
Poem
🚥 Pre-merge checks | ✅ 4 | ❌ 1❌ Failed checks (1 warning)
✅ Passed checks (4 passed)
✏️ Tip: You can configure your own custom pre-merge checks in the settings. Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out. Comment |
There was a problem hiding this comment.
💡 Codex Review
Here are some automated review suggestions for this pull request.
Reviewed commit: 59fc8f1e98
ℹ️ About Codex in GitHub
Codex has been enabled to automatically review pull requests in this repo. Reviews are triggered when you
- Open a pull request for review
- Mark a draft as ready
- Comment "@codex review".
If Codex has suggestions, it will comment; otherwise it will react with 👍.
When you sign up for Codex through ChatGPT, Codex can also answer questions or update the PR, like "@codex address that feedback".
|
|
||
| if (IS_HEADLESS_EXPORT) { | ||
| // Force HEADLESS=true so createEditorWindow uses `show: false`. | ||
| process.env.HEADLESS = "true"; |
There was a problem hiding this comment.
Initialize headless mode before window module reads env
Setting process.env.HEADLESS here is too late for the --export path because main.ts has already imported createEditorWindow from electron/windows.ts, and that module snapshots const HEADLESS = process.env["HEADLESS"] === "true" at import time. In runs where the env var was not pre-set, the editor window is still constructed with show: true, so the supposedly headless CLI export can still open/focus a UI window.
Useful? React with 👍 / 👎.
| await applyLoadedProjectRef.current( | ||
| payload.project as Parameters<typeof applyLoadedProject>[0], | ||
| payload.projectPath, | ||
| ); |
There was a problem hiding this comment.
Fail headless export when project restore is invalid
applyLoadedProject returns false for invalid project payloads, but this headless path ignores that result and proceeds to export anyway. That means a malformed .openscreen file can lead to exporting stale/default loaded media (or waiting until timeout) instead of failing fast for bad input, which is risky for batch/CI automation correctness.
Useful? React with 👍 / 👎.
There was a problem hiding this comment.
Actionable comments posted: 3
Caution
Some comments are outside the diff and can’t be posted inline due to platform limitations.
⚠️ Outside diff range comments (1)
electron/main.ts (1)
480-486:⚠️ Potential issue | 🟠 Major | ⚡ Quick winHeadless mode still boots UI chrome before branching.
app.dock?.show(),createTray(),updateTrayMenu(), andsetupApplicationMenu()all run beforerunHeadlessExport(). So--exportis not actually UI-free on startup; Windows/Linux can still get a tray icon, and macOS can briefly promote the app before you hide it again.🪟 Keep the UI bootstrap out of the headless path
- if (process.platform === "darwin") { + if (process.platform === "darwin" && !IS_HEADLESS_EXPORT) { app.dock?.show(); } ... - createTray(); - updateTrayMenu(); - setupApplicationMenu(); + if (!IS_HEADLESS_EXPORT) { + createTray(); + updateTrayMenu(); + setupApplicationMenu(); + }Also applies to: 551-553, 585-589
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the rest with a brief reason, keep changes minimal, and validate. In `@electron/main.ts` around lines 480 - 486, The startup currently runs UI bootstrap (app.dock?.show(), createTray(), updateTrayMenu(), setupApplicationMenu()) before checking for headless/export mode so the UI briefly appears; change the app.whenReady() flow to first call runHeadlessExport() and, only if it returns/indicates non-headless, perform the UI setup calls (app.dock?.show(), createTray(), updateTrayMenu(), setupApplicationMenu()). Locate these symbols inside the app.whenReady().then(async () => { ... }) block and guard or reorder them so the headless path exits/returns before any dock/tray/menu code runs; apply the same conditional guard to the other occurrences noted (around the blocks at the later sites).
🧹 Nitpick comments (1)
electron/electron-env.d.ts (1)
237-245: ⚡ Quick winNit: reuse
HeadlessExportPayloadhere instead of inlining it.This payload shape now exists in two places, which is lowkey risky for a main↔preload↔renderer contract. Cleaner to point this signature at one shared type so it can't drift.
♻️ Cleaner type wiring
+type HeadlessExportPayload = import("./preload").HeadlessExportPayload; + interface Window { electronAPI: { ... onHeadlessExportTrigger: ( - callback: (payload: { - projectPath: string; - project: unknown; - format: "mp4" | "gif"; - quality: "good" | "medium" | "source"; - outputPath: string; - }) => void, + callback: (payload: HeadlessExportPayload) => void, ) => () => void;🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the rest with a brief reason, keep changes minimal, and validate. In `@electron/electron-env.d.ts` around lines 237 - 245, The onHeadlessExportTrigger callback currently inlines the payload shape; update its signature to reuse the existing HeadlessExportPayload type instead of duplicating the structure. Locate the onHeadlessExportTrigger declaration and replace the inline payload object with (payload: HeadlessExportPayload) => void so the main↔preload↔renderer contract points to the single shared HeadlessExportPayload type (ensure the type is imported or available in the same scope).
🤖 Prompt for all review comments with AI agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.
Inline comments:
In `@electron/main.ts`:
- Around line 45-49: Ensure headless export args are validated and fail fast:
when HEADLESS_EXPORT_PROJECT (from getCliArg("export")) is present require
HEADLESS_EXPORT_OUTPUT (from getCliArg("output")) and validate
HEADLESS_EXPORT_FORMAT and HEADLESS_EXPORT_QUALITY values before setting
IS_HEADLESS_EXPORT; if format is not exactly "mp4" or "gif" or quality is not
"good" | "medium" | "source", log a clear error (including the bad value and
accepted values) and exit process with non‑zero status so the headless path is
not enabled accidentally.
In `@src/components/video-editor/VideoEditor.tsx`:
- Around line 1972-1977: The callback that handles payload.project must not
swallow headless setup failures: check the boolean result of applyLoadedProject
(used in VideoEditor.applyLoadedProject via applyLoadedProjectRef.current) and
if it returns false or throws, immediately report the failure to the main
process via a small IPC (e.g., reportHeadlessExportFailure) instead of merely
logging and returning; update the same pattern in the other callback range (the
block spanning the nearby lines 1981–2012) to call the preload IPC on failure
and rethrow or abort the export flow so the app exits with a non-zero code, and
add corresponding IPC handlers in preload/main to receive
reportHeadlessExportFailure and exit/return failure to CI.
- Around line 1992-2003: The headless GIF export hardcodes gifConfig
width/height and sizePreset (1280x720, "medium") in ExportSettings, causing
incorrect aspect ratios for portrait/square projects; update the headless branch
that sets payload.format === "gif" to compute gifConfig using the same sizing
logic used by the interactive path (reuse the dimension calculation used in
applyLoadedProject / handleExport), pulling the current cropRegion, aspectRatio,
and gifSizePreset values instead of hardcoded values, and populate
gifConfig.width, gifConfig.height and sizePreset accordingly so CLI exports
match editor state.
---
Outside diff comments:
In `@electron/main.ts`:
- Around line 480-486: The startup currently runs UI bootstrap
(app.dock?.show(), createTray(), updateTrayMenu(), setupApplicationMenu())
before checking for headless/export mode so the UI briefly appears; change the
app.whenReady() flow to first call runHeadlessExport() and, only if it
returns/indicates non-headless, perform the UI setup calls (app.dock?.show(),
createTray(), updateTrayMenu(), setupApplicationMenu()). Locate these symbols
inside the app.whenReady().then(async () => { ... }) block and guard or reorder
them so the headless path exits/returns before any dock/tray/menu code runs;
apply the same conditional guard to the other occurrences noted (around the
blocks at the later sites).
---
Nitpick comments:
In `@electron/electron-env.d.ts`:
- Around line 237-245: The onHeadlessExportTrigger callback currently inlines
the payload shape; update its signature to reuse the existing
HeadlessExportPayload type instead of duplicating the structure. Locate the
onHeadlessExportTrigger declaration and replace the inline payload object with
(payload: HeadlessExportPayload) => void so the main↔preload↔renderer contract
points to the single shared HeadlessExportPayload type (ensure the type is
imported or available in the same scope).
🪄 Autofix (Beta)
Fix all unresolved CodeRabbit comments on this PR:
- Push a commit to this branch (recommended)
- Create a new PR with the fixes
ℹ️ Review info
⚙️ Run configuration
Configuration used: Organization UI
Review profile: CHILL
Plan: Pro
Run ID: 8429088a-a7f9-4e41-9ad5-df298c1c7cb2
📒 Files selected for processing (4)
electron/electron-env.d.tselectron/main.tselectron/preload.tssrc/components/video-editor/VideoEditor.tsx
| const HEADLESS_EXPORT_PROJECT = getCliArg("export"); | ||
| const HEADLESS_EXPORT_OUTPUT = getCliArg("output"); | ||
| const HEADLESS_EXPORT_FORMAT = (getCliArg("format") ?? "mp4") as "mp4" | "gif"; | ||
| const HEADLESS_EXPORT_QUALITY = (getCliArg("quality") ?? "good") as "good" | "medium" | "source"; | ||
| const IS_HEADLESS_EXPORT = Boolean(HEADLESS_EXPORT_PROJECT && HEADLESS_EXPORT_OUTPUT); |
There was a problem hiding this comment.
Validate the headless CLI args before enabling this path.
Right now --export without --output quietly falls back to the normal app, and unknown --format / --quality values are just cast through. In automation that's kinda cursed: a typo can open the UI or send MP4 bytes to a .gif path. Fail fast here instead of treating bad args as valid.
🛠️ Possible guardrail
const HEADLESS_EXPORT_PROJECT = getCliArg("export");
const HEADLESS_EXPORT_OUTPUT = getCliArg("output");
-const HEADLESS_EXPORT_FORMAT = (getCliArg("format") ?? "mp4") as "mp4" | "gif";
-const HEADLESS_EXPORT_QUALITY = (getCliArg("quality") ?? "good") as "good" | "medium" | "source";
-const IS_HEADLESS_EXPORT = Boolean(HEADLESS_EXPORT_PROJECT && HEADLESS_EXPORT_OUTPUT);
+const rawHeadlessExportFormat = getCliArg("format") ?? "mp4";
+const rawHeadlessExportQuality = getCliArg("quality") ?? "good";
+const IS_HEADLESS_EXPORT =
+ HEADLESS_EXPORT_PROJECT !== undefined || HEADLESS_EXPORT_OUTPUT !== undefined;
+
+if (IS_HEADLESS_EXPORT && (!HEADLESS_EXPORT_PROJECT || !HEADLESS_EXPORT_OUTPUT)) {
+ throw new Error("`--export` and `--output` must be provided together");
+}
+
+if (rawHeadlessExportFormat !== "mp4" && rawHeadlessExportFormat !== "gif") {
+ throw new Error(`Unsupported export format: ${rawHeadlessExportFormat}`);
+}
+
+if (
+ rawHeadlessExportQuality !== "good" &&
+ rawHeadlessExportQuality !== "medium" &&
+ rawHeadlessExportQuality !== "source"
+) {
+ throw new Error(`Unsupported export quality: ${rawHeadlessExportQuality}`);
+}
+
+const HEADLESS_EXPORT_FORMAT = rawHeadlessExportFormat;
+const HEADLESS_EXPORT_QUALITY = rawHeadlessExportQuality;📝 Committable suggestion
‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.
| const HEADLESS_EXPORT_PROJECT = getCliArg("export"); | |
| const HEADLESS_EXPORT_OUTPUT = getCliArg("output"); | |
| const HEADLESS_EXPORT_FORMAT = (getCliArg("format") ?? "mp4") as "mp4" | "gif"; | |
| const HEADLESS_EXPORT_QUALITY = (getCliArg("quality") ?? "good") as "good" | "medium" | "source"; | |
| const IS_HEADLESS_EXPORT = Boolean(HEADLESS_EXPORT_PROJECT && HEADLESS_EXPORT_OUTPUT); | |
| const HEADLESS_EXPORT_PROJECT = getCliArg("export"); | |
| const HEADLESS_EXPORT_OUTPUT = getCliArg("output"); | |
| const rawHeadlessExportFormat = getCliArg("format") ?? "mp4"; | |
| const rawHeadlessExportQuality = getCliArg("quality") ?? "good"; | |
| const IS_HEADLESS_EXPORT = | |
| HEADLESS_EXPORT_PROJECT !== undefined || HEADLESS_EXPORT_OUTPUT !== undefined; | |
| if (IS_HEADLESS_EXPORT && (!HEADLESS_EXPORT_PROJECT || !HEADLESS_EXPORT_OUTPUT)) { | |
| throw new Error("`--export` and `--output` must be provided together"); | |
| } | |
| if (rawHeadlessExportFormat !== "mp4" && rawHeadlessExportFormat !== "gif") { | |
| throw new Error(`Unsupported export format: ${rawHeadlessExportFormat}`); | |
| } | |
| if ( | |
| rawHeadlessExportQuality !== "good" && | |
| rawHeadlessExportQuality !== "medium" && | |
| rawHeadlessExportQuality !== "source" | |
| ) { | |
| throw new Error(`Unsupported export quality: ${rawHeadlessExportQuality}`); | |
| } | |
| const HEADLESS_EXPORT_FORMAT = rawHeadlessExportFormat; | |
| const HEADLESS_EXPORT_QUALITY = rawHeadlessExportQuality; |
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.
In `@electron/main.ts` around lines 45 - 49, Ensure headless export args are
validated and fail fast: when HEADLESS_EXPORT_PROJECT (from getCliArg("export"))
is present require HEADLESS_EXPORT_OUTPUT (from getCliArg("output")) and
validate HEADLESS_EXPORT_FORMAT and HEADLESS_EXPORT_QUALITY values before
setting IS_HEADLESS_EXPORT; if format is not exactly "mp4" or "gif" or quality
is not "good" | "medium" | "source", log a clear error (including the bad value
and accepted values) and exit process with non‑zero status so the headless path
is not enabled accidentally.
| if (payload.project) { | ||
| await applyLoadedProjectRef.current( | ||
| payload.project as Parameters<typeof applyLoadedProject>[0], | ||
| payload.projectPath, | ||
| ); | ||
| } |
There was a problem hiding this comment.
Don't swallow headless setup failures here.
applyLoadedProject() can return false, the 60s readiness loop can expire, and handleExport() can still no-op on “video not ready”. This callback just logs and returns, so the main process only learns about the failure via its 10-minute watchdog. For CI, that's lowkey brutal — this needs an explicit failure path back to main.
🚨 Fail fast instead of timing out
- if (payload.project) {
- await applyLoadedProjectRef.current(
+ if (payload.project) {
+ const applied = await applyLoadedProjectRef.current(
payload.project as Parameters<typeof applyLoadedProject>[0],
payload.projectPath,
);
+ if (!applied) {
+ throw new Error("Project could not be loaded for headless export");
+ }
}
- const deadline = Date.now() + 60_000;
+ const deadline = Date.now() + 60_000;
+ let videoReady = false;
while (Date.now() < deadline) {
const v = videoPlaybackRefRef.current?.video;
- if (v && v.readyState >= 2 && v.duration > 0) break;
+ if (v && v.readyState >= 2 && v.duration > 0) {
+ videoReady = true;
+ break;
+ }
await new Promise((r) => setTimeout(r, 200));
}
+ if (!videoReady) {
+ throw new Error("Video never became ready for headless export");
+ }
...
} catch (err) {
console.error("[headless-export] failed:", err);
+ window.electronAPI.reportHeadlessExportFailure?.(
+ err instanceof Error ? err.message : String(err),
+ );
}You'll need a tiny preload/main IPC for reportHeadlessExportFailure so the app can exit immediately with a non-zero code.
Also applies to: 1981-2012
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.
In `@src/components/video-editor/VideoEditor.tsx` around lines 1972 - 1977, The
callback that handles payload.project must not swallow headless setup failures:
check the boolean result of applyLoadedProject (used in
VideoEditor.applyLoadedProject via applyLoadedProjectRef.current) and if it
returns false or throws, immediately report the failure to the main process via
a small IPC (e.g., reportHeadlessExportFailure) instead of merely logging and
returning; update the same pattern in the other callback range (the block
spanning the nearby lines 1981–2012) to call the preload IPC on failure and
rethrow or abort the export flow so the app exits with a non-zero code, and add
corresponding IPC handlers in preload/main to receive
reportHeadlessExportFailure and exit/return failure to CI.
| const settings: ExportSettings = { | ||
| format: payload.format, | ||
| quality: payload.format === "mp4" ? payload.quality : undefined, | ||
| gifConfig: | ||
| payload.format === "gif" | ||
| ? { | ||
| frameRate: 30 as GifFrameRate, | ||
| loop: true, | ||
| sizePreset: "medium" as GifSizePreset, | ||
| width: 1280, | ||
| height: 720, | ||
| } |
There was a problem hiding this comment.
Headless GIF export ignores the project's aspect ratio.
This always forces 1280x720 + "medium", so portrait and square projects will export as 16:9 in CLI mode even after you just applied the saved editor state. Reuse the same GIF dimension calculation as the interactive path once the video is ready.
🎞️ Match the normal GIF sizing path
- const settings: ExportSettings = {
+ const video = videoPlaybackRefRef.current?.video;
+ const sourceWidth = video?.videoWidth || 1920;
+ const sourceHeight = video?.videoHeight || 1080;
+ const effectiveSourceDimensions = calculateEffectiveSourceDimensions(
+ sourceWidth,
+ sourceHeight,
+ cropRegion,
+ );
+ const aspectRatioValue =
+ aspectRatio === "native"
+ ? getNativeAspectRatioValue(sourceWidth, sourceHeight, cropRegion)
+ : getAspectRatioValue(aspectRatio);
+ const gifDimensions = calculateOutputDimensions(
+ effectiveSourceDimensions.width,
+ effectiveSourceDimensions.height,
+ gifSizePreset,
+ GIF_SIZE_PRESETS,
+ aspectRatioValue,
+ );
+
+ const settings: ExportSettings = {
format: payload.format,
quality: payload.format === "mp4" ? payload.quality : undefined,
gifConfig:
payload.format === "gif"
? {
frameRate: 30 as GifFrameRate,
loop: true,
- sizePreset: "medium" as GifSizePreset,
- width: 1280,
- height: 720,
+ sizePreset: gifSizePreset,
+ width: gifDimensions.width,
+ height: gifDimensions.height,
}
: undefined,
};That probably wants refs for cropRegion, aspectRatio, and gifSizePreset the same way you're already stabilizing applyLoadedProject and handleExport.
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.
In `@src/components/video-editor/VideoEditor.tsx` around lines 1992 - 2003, The
headless GIF export hardcodes gifConfig width/height and sizePreset (1280x720,
"medium") in ExportSettings, causing incorrect aspect ratios for portrait/square
projects; update the headless branch that sets payload.format === "gif" to
compute gifConfig using the same sizing logic used by the interactive path
(reuse the dimension calculation used in applyLoadedProject / handleExport),
pulling the current cropRegion, aspectRatio, and gifSizePreset values instead of
hardcoded values, and populate gifConfig.width, gifConfig.height and sizePreset
accordingly so CLI exports match editor state.
|
what is even the motivation behind this? |
Hey ! So I was just missing that CLI based output, as otherwise everything is already ready in your codebase. Happy to discuss more about it. |
|
Sorry but will have to close this - not in scope. |
Summary
Adds an
--exportCLI flag that lets OpenScreen render a.openscreenproject to MP4/GIF entirely headlessly — no window, no save dialog, no user interaction. Enables batch exports from shell scripts / CI.Usage
What it does
When
--exportis set:HEADLESS=truesocreateEditorWindowusesshow: false(the existing offscreen path).app.dock.hide()).pick-export-save-pathIPC to auto-return the--outputpath (no file dialog).write-export-to-pathIPC to write the rendered blob and quit cleanly.trigger-headless-exportIPC to the renderer.The renderer-side hook in
VideoEditor.tsx:project.editor.annotationRegionsfor uniquefontFamilyreferences and preloads each viaaddCustomFont()soctx.fontdoesn't silently fall back to sans-serif (Canvas font rendering doesn't error on missing fonts — just substitutes).applyLoadedProject()to apply trim/zoom/annotation/cursor regions.videoPlaybackRef.current.video.readyState >= 2andduration > 0.handleExport(settings)directly with the requested format/quality.Files changed
electron/main.ts— CLI parsing +runHeadlessExport()driverelectron/preload.ts— exposesonHeadlessExportTrigger()with a typed payloadelectron/electron-env.d.ts— TS types for the new APIsrc/components/video-editor/VideoEditor.tsx— renderer-side listener (font preload + project apply + handleExport)262 lines added across 4 files. No upstream code paths touched outside the new branches.
Test plan
./node_modules/.bin/electron . --export X.openscreen --output Y.mp4produces a valid MP4 with trim/zoom/annotation regions applied.Why
The existing GIF e2e test (
tests/e2e/gif-export.spec.ts) shows the headless export pipeline works, but driving it requires Playwright, knowing the React testIds, and clicking UI elements that can move between versions. A first-class CLI is more useful and is what most "render this for me" automation actually wants.